Skip to content

[rl] Pin renderers 0.1.11, render with TorchTitan's tokenizer, take the renderer's typed config directly - #43

Open
felipemello1 wants to merge 20 commits into
mainfrom
80-renderers-typed
Open

[rl] Pin renderers 0.1.11, render with TorchTitan's tokenizer, take the renderer's typed config directly#43
felipemello1 wants to merge 20 commits into
mainfrom
80-renderers-typed

Conversation

@felipemello1

@felipemello1 felipemello1 commented Sep 2, 2026

Copy link
Copy Markdown
Owner

TLDR:

  • pin renderers
  • Use our tokenizer and remove transformers dependency
  • Simplify code by using config classes directly rather than trying to map model_name -> config_type
  • Simplify code by removing the need of registering muse glimmer

Summary

TitanRL uses renderers to turn chat messages into token ids and parse completions back. Until now we wrapped it in our own RendererConfig:

# before: recipe
renderer=RendererConfig(name="qwen3", enable_thinking=False)

# before: RendererConfig.build()
tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)                  # transformers
args = {f.name: v for f in fields(self) if f.name in config_type.model_fields}   # silent drop
return create_renderer(tokenizer, config_type(**args))

Problems:

  1. Silent drops ([rl] RendererConfig silently drops the removed preserve_* renderer knobs pytorch/torchtitan#4365). build() forwarded knobs by name-matching against the renderer's config and dropped the rest. When renderers replaced preserve_all_thinking with thinking_retention, the knob went inert with no error; two in-tree recipes were already hitting the same thing (enable_thinking on gpt-oss and Muse Glimmer, which have no such option).
  2. A second tokenizer. build() loaded transformers.AutoTokenizer even though TorchTitan already has the tokenizer, and the controller then reached into renderer._tokenizer for pad_id.
  3. Transformers dependency

Solutions:

renderers==0.1.11 accepts a bring-your-own tokenizer and drops the transformers dependency:

  1. Recipes hold the library's typed config directly: We don't have a wrapper anymore. We don't try to redirect 'qwen3to the right config, e.g.renderer=RendererConfig(name="qwen3", enable_thinking=False)`. Instead, we do:

    from renderers import Qwen3RendererConfig, GptOssRendererConfig
    renderer=Qwen3RendererConfig(enable_thinking=False)
    renderer=GptOssRendererConfig(reasoning_effort="low")
  2. We load our own tokenizer:build_renderer pairs that config with TorchTitan's tokenizer, through a small adapter (RendererTokenizer) that exposes the interface renderers expects.

    # controller / rollout worker / generate.py
    tokenizer = HuggingFaceTokenizer(tokenizer_path=hf_assets_path)
    renderer = build_renderer(tokenizer=tokenizer, config=config.renderer)
    pad_id = tokenizer.eos_id
    
    def build_renderer(*, tokenizer: HuggingFaceTokenizer, config: BaseRendererConfig) -> Renderer:
        if config.name == "auto": raise ValueError(...)      # unmatched local paths fall back unsafely
        if config.name == "default": raise ValueError(...)   # needs HF apply_chat_template
        renderer_tokenizer = RendererTokenizer(tokenizer)
        if isinstance(config, TorchTitanRendererConfig):     # renderer implemented in TorchTitan
            return config.renderer_cls(renderer_tokenizer, config)
        return create_renderer(tokenizer=renderer_tokenizer, config=config)
  3. We skip registration of new renderers*: Muse Glimmer's renderer lives in TorchTitan, so the renderer's registry cannot find it. We skip the need for the renderers registry that maps config -> renderer class. Check TorchTitanRendererConfig.

Validation

  • Alphabet sort, Qwen3-0.6B, 2 GPUs: validation reward 0.181 -> 0.548.

@felipemello1
felipemello1 force-pushed the 80-renderers-typed branch 9 times, most recently from 65d12cd to 0d1de2a Compare September 3, 2026 15:11
@felipemello1 felipemello1 changed the title [rl] Pin renderers 0.1.11, BYO tokenizer, take the renderer's typed config directly (alternative to #42) [rl] Pin renderers 0.1.11, render with TorchTitan's tokenizer, take the renderer's typed config directly Sep 3, 2026
…he renderer's typed config directly

renderers 0.1.11 makes transformers optional and accepts a bring-your-own
tokenizer. RL now renders with TorchTitan's HuggingFaceTokenizer (wrapped in
RendererTokenizer to satisfy renderers.OffsetTokenizer) instead of loading
transformers.AutoTokenizer, and the controller reads pad_id off its own
tokenizer instead of renderer._tokenizer.

TorchTitan's RendererConfig wrapper is removed. Controller.Config.renderer is
the library's own typed pydantic config (Qwen3RendererConfig(enable_thinking=False),
GptOssRendererConfig(reasoning_effort="low"), ...), so a wrong option fails
when the recipe is constructed instead of being silently dropped (pytorch#4365), and
TorchTitan mirrors no renderer fields. build_renderer(tokenizer, config) is the
one TorchTitan-side seam. A renderer that ships in TorchTitan (Muse Glimmer)
has a TorchTitanRendererConfig that names its renderer class, and build_renderer
constructs it directly, so nothing is written into renderers' registry.
AutoRendererConfig and DefaultRendererConfig are rejected with the reason: the
former depends on an exact model-ID match that local asset paths do not reliably
preserve; the latter needs Hugging Face-compatible apply_chat_template semantics,
which TorchTitan's template rendering does not provide.
Renderer options are set in the recipe (tyro.conf.Suppress, like model_spec);
the 8 redundant --renderer.enable-thinking CLI flags in the integration tests
are removed. Configurable.to_dict learns pydantic model_dump so the wandb config
stays JSON.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
pianpwk and others added 2 commits September 3, 2026 10:24
## Summary

- keep the H100 README badge scoped to `branch=main`
- add a B200 integration-test badge scoped to `branch=main`
- run both H100 and B200 workflows from CIFlow tags and once daily at
midnight UTC
- remove the B200 push-to-main, path, and manual-dispatch triggers

## Why

The H100 badge filtered for `main`, but the workflow had no scheduled or
push-to-main runs and therefore no matching status. Daily scheduled runs
provide a consistent `main` status for both dedicated accelerator lanes,
while CIFlow tags retain on-demand testing without running B200 on every
matching push to `main`.

## Test plan

- `/data/users/jianiw/torchtitan/.venv/bin/pre-commit run --files
README.md .github/workflows/integration_test_h100.yaml
.github/workflows/integration_test_b200.yaml`
- verified the H100 and B200 workflow links resolve successfully
QIU023 and others added 13 commits September 3, 2026 12:11
### Summary

Enables expert parallelism for Kimi K3 on the existing all-to-all token
dispatcher. Expert parallel comes off the unsupported list; the
dispatcher backend is a spec parameter, `moe_comm_backend`, threaded
from `model_registry` to `make_token_dispatcher_config` the way
deepseek_v3 and gpt_oss do it: `standard` (PyTorch all-to-all) by
default, `deepep`, `hybridep` and `minimal_async_ep` selectable as on
those models. The tables below are measured on `standard`. MoonEP (the
report's balanced EP with online redundant-expert planning) is not here;
it needs its own dispatcher and backend.

### EP Design

#### Design points

- `set_moe_sharding_config` declares the routed-expert layout, `w1_EFD
S(1), w2_EDF S(2), w3_EFD S(1)`, on the expert axis
(`sharding.py:26-46`, `set_expert_parallel_sharding_config`).
- `set_decoder_sharding_config` sits above it so the activations
reaching the MoE boundary are DTensors that can be redistributed onto
the expert mesh.
- parallelize builds the expert-data-parallel mesh, `efsdp`, that
excludes the expert axis and hands it to `apply_fsdp_to_decoder` with
`ep_degree` (`parallelize.py:67-69`, `parallelize.py:104`): the shape
`deepseek_v3` already resolves, so expert parameters shard on `(dp_shard
x cp x tp) / ep` and everything else on the full data axis.
- Without `expert_parallel_degree > 1` none of this executes.

### K3 EP runs:

To reproduce, from the torchtitan checkout root on this branch, 8 GPUs.
Every cell loads the same seed checkpoint; run each cell twice and read
the second run (a cold compile cache moves step 1). The runner we used,
with the seed-load assertion and a disk gate, is
https://github.com/QIU023/torchtitan_attention_residual/blob/611385d4e123d4d0527c6d08b06f8d701bb63e21/phase13_k3like_48b_posttrain/matrix_scripts/mx3.sh.

```sh
COMMON="-m torchtitan.train --module kimi_k3 --config kimi_k3_debugmodel --debug.seed 42 --debug.deterministic --training.num-tokens-per-train-step 8192 --training.num-tokens-per-microbatch-per-dp-rank 256 --checkpoint.enable"
torchrun --nproc_per_node=1 $COMMON --training.steps 1 --parallelism.data_parallel_shard_degree 1 --checkpoint.create_seed_checkpoint --dump-folder seed
cell() { d=$1; n=$2; shift 2; rm -rf $d; mkdir -p $d; cp -r seed/checkpoint $d/; torchrun --nproc_per_node=$n $COMMON --training.steps 10 --metrics.log_freq 1 --checkpoint.interval 100000 "$@" --dump-folder $d; }
D="--parallelism.data_parallel_shard_degree"; E="--parallelism.expert_parallel_degree"
cell dp1 1 $D 1;  cell dp2 2 $D 2;  cell ep2_fsdp2 2 $D 2 $E 2
cell dp4 4 $D 4;  cell ep4_fsdp4 4 $D 4 $E 4;  cell dp8 8 $D 8;  cell ep8_fsdp8 8 $D 8 $E 8
```

EP shards experts inside the data axis, so each cell is compared against
the pure-data-parallel run at the same world size. `kimi_k3_debugmodel`,
seed 42, `--debug.deterministic`, one seed checkpoint loaded by every
cell; at step 2 the EP cells are 3.3e-3, 9.0e-3 and 1.6e-5 from their
own-degree baseline, against 1.7e-2, 2.5e-2 and 5.2e-2 for the dp2, dp4
and dp8 rows measured the same way against dp1 -- sharding the experts
moves the loss less than changing the data-parallel degree does, at
every degree:

| cell | world | step 1 | step 3 | step 10 |
|---|---|---|---|---|
| dp1 | 1 | 12.59245 | 7.46936 | 3.18111 |
| dp2 | 2 | 12.58904 | 7.61204 | 3.33502 |
| ep2 x fsdp2 | 2 | 12.59108 | 7.59337 | 3.29070 |
| dp4 | 4 | 12.58372 | 7.65474 | 3.24291 |
| ep4 x fsdp4 | 4 | 12.58128 | 7.59719 | 3.17962 |
| dp8 | 8 | 12.60943 | 8.26416 | 3.35955 |
| ep8 x fsdp8 | 8 | 12.61045 | 8.38947 | 3.20613 |

### Changed files

    torchtitan/models/common/
      token_dispatcher.py    +4/-1  a hidden_dim set on the dispatcher
config wins; model dim is only the default
                                   (K3 dispatches the latent stream)
      config_utils.py           +1  the minimal_async_ep factory branch
                                   forwards hidden_dim
    torchtitan/models/kimi_k3/
sharding.py +47 set_expert_parallel_sharding_config: the
routed-expert layout (new file, following
                                   qwen3_5/sharding.py)
      model.py                  +3  the one call under the ep>1 gate
__init__.py +23/-6 moe_comm_backend threads from model_registry
to core's dispatcher factory: standard by
default, deepep / hybridep / minimal_async_ep
                                   selectable as on deepseek_v3
      parallelize.py         +17/-2 the efsdp mesh, ep_degree through to
apply_fsdp_to_decoder, expert parallel off
the unsupported list, and a comment naming
                                   the backends this model runs
    tests/integration_tests/models.py       the model test gains ep2
    torchtitan_recipes/tests/models.py    its configuration

### CI/CD Coverage

The existing kimi_k3 model integration test becomes fsdp2 x ep2 on the
same 2 GPUs.

### Numerical Correction run with unmerged upstream grad-norm precision
forced to FP32

The same matrix with the grad-norm reduction carried in float32
(pytorch#4135, a separate change not
on this branch): dp1, dp4 and ep4 move (largest 2.0e-2 at step 3, on the
dp1 baseline itself) and the other four cells are bitwise unchanged. The
total norm is reduced over an expert group and a non-expert group
separately, and which cells cross a bf16 rounding boundary shifts with
what is in the groups -- the vision tower's parameters are part of the
non-expert group here.

| cell | world | step 1 | step 3 | step 10 |
|---|---|---|---|---|
| dp1 | 1 | 12.59245 | 7.62176 | 3.19563 |
| dp2 | 2 | 12.58904 | 7.61204 | 3.32351 |
| ep2 x fsdp2 | 2 | 12.59108 | 7.59337 | 3.28553 |
| dp4 | 4 | 12.58372 | 7.61710 | 3.23680 |
| ep4 x fsdp4 | 4 | 12.58128 | 7.61100 | 3.14353 |
| dp8 | 8 | 12.60943 | 8.26416 | 3.33049 |
| ep8 x fsdp8 | 8 | 12.61045 | 8.38947 | 3.20156 |

### EP backend verify result

2 x H100 SXM (NVLink), torch 2.15.0.dev20260827+cu130, DeepEP v2 at the
commit CI pins (`01dc3aa`). `kimi_k3_debugmodel`, seed 42,
`--debug.deterministic`, one seed checkpoint loaded by every cell.
MinimalAsyncEP requires full activation checkpointing, so a `standard`
full-AC row isolates that. `hybridep` is not run: it lives on DeepEP's
`hybrid-ep` branch and targets GB200 / NVL72.

The backend is a `model_registry` parameter rather than a flavor, so the
cells select it in a configuration function rather than on the command
line:

```python
# torchtitan_recipes/k3_ep_backends.py
from torchtitan.models.kimi_k3 import model_registry
from torchtitan.models.kimi_k3.config_registry import kimi_k3_debugmodel

def _backend(name: str) -> Trainer.Config:
    config = kimi_k3_debugmodel()
    config.model_spec = model_registry("debugmodel", moe_comm_backend=name)
    return config

def k3_deepep() -> Trainer.Config: return _backend("deepep")
def k3_minimal_async_ep() -> Trainer.Config: return _backend("minimal_async_ep")
```

```sh
BACKEND_COMMON="-m torchtitan.train --module torchtitan_recipes.k3_ep_backends --debug.seed 42 --debug.deterministic --training.steps 10 --metrics.log_freq 1 --training.num-tokens-per-train-step 8192 --training.num-tokens-per-microbatch-per-dp-rank 256 --parallelism.data_parallel_shard_degree 2 --parallelism.expert_parallel_degree 2"
torchrun --nproc_per_node=2 $BACKEND_COMMON --config k3_minimal_async_ep
# DeepEP v2 on a host with no RDMA NIC needs its own environment:
CUDA_HOME=/usr/local/cuda-13.0 NCCL_NVLS_ENABLE=0 EP_DISABLE_GIN=1 EP_REUSE_NCCL_COMM=0 \
  NVSHMEM_REMOTE_TRANSPORT=none NVSHMEM_DISABLE_MNNVL=1 \
  torchrun --nproc_per_node=2 $BACKEND_COMMON --config k3_deepep
```

| cell | backend | AC | step 1 | step 3 | step 10 |
|---|---|---|---|---|---|
| dp2 | - | selective | 12.59951 | 7.45599 | 3.26481 |
| ep2 x fsdp2 | standard | selective | 12.59951 | 7.43228 | 3.30036 |
| ep2 x fsdp2 | standard | full | 12.59951 | 7.43228 | 3.30036 |
| ep2 x fsdp2 | minimal_async_ep | full | 12.59768 | 7.56392 | 3.29721 |
| ep2 x fsdp2 | deepep | selective | 12.59438 | 7.46660 | 3.24408 |

Re-measured on the review-round head (`hidden_dim` set at construction):
dp2, `standard` and `standard` full-AC reproduce the rows above to every
printed digit, so the dispatcher-config change is numerically inert.

`standard` prints dp2's step-1 loss to every digit and its full-AC twin
matches it through step 10; `deepep` differs by ordinary backend
arithmetic. `minimal_async_ep`'s step-3 gap is an upstream
MinimalAsyncEP bug, not K3's: the expert GEMM saves a view of the
recycled receive buffer that the combine backward overwrites, so the
routed `w1_EFD` / `w3_EFD` gradients are lost; the one-line
owned-dispatch fix goes in a separate PR, and with it the same cell is
back in the noise band.
## Summary

Correct DeepSeek V4 model FLOPs estimation used by TorchTitan
metrics/MFU.

The estimator now follows the current TorchTitan model accounting
pattern:

- use `get_nparams_and_active_nparams()` for total and active MoE
parameters;
- count DeepSeek V4 sliding-window and compressed sparse attention from
the final layer configs;
- include the CSA indexer score contraction;
- include MTP attention plus repeated LM-head and multi-stream `h_proj`
work.

This keeps the calculation model- and backend-independent; it does not
encode NPU/AscendC implementation details.

## Validation

Adds a CPU-suite meta-device regression test for `deepseek_v4_flash`
with one MTP layer at sequence length 4096:

- total parameters: `290,942,278,866`
- model FLOPs/token: `92,762,352,876`

The test builds the complete model on `meta` and calls the same
`Model.Config.get_nparams_and_flops()` integration point used by
metrics.

---------

Co-authored-by: depeng <166494784+depeng1994@users.noreply.github.com>
## Summary

- Set eps=1e-6 directly on every DeepSeek RMSNorm config, matching the
DeepSeek V2/V3 reference configuration.
- Avoid post-construction config traversal; each norm now declares its
model-specific epsilon alongside its other parameters.
- Refresh the affected fake-PG and CP/PP loss and grad-norm baselines
using the full-precision values from CI artifacts.
- Rebase onto the latest main branch.

The shared RMSNorm default remains 1e-5, so other model families such as
Kimi are unchanged.

DeepSeek reference:
https://huggingface.co/deepseek-ai/DeepSeek-V3/blob/main/config.json

## Validation

- Pyrefly on torchtitan/models/deepseek_v3/__init__.py: 0 errors
- 31 relevant CPU unit tests passed
- All RMSNorm configs in debugmodel, 16B, 236B, and 671B use 1e-6,
including MTP configs
- Updated loss and grad-norm baselines exactly match the failed CI
TensorBoard artifacts
- Targeted pre-commit hooks passed (Pyrefly run separately)
Math-Verify would parse a response like this:
```
 " Final Answer:
  The last three digits of \(2003^{2002^{2001}}\) are:
  \[Answer: 009\]"
```

and try to calculate "(2003^{2002^{2001}", leading to a hang. In the
past, this was not an issue. We would just timeout. But currently, we
cannot timeout due to some monarch issues and math_verify not being in
the main thread.

This PR workaround is to improve the parser. Now we demand the answer to
be in `\boxed(...)`. I ran 50 steps without hanging.
https://meta.wandb.io/felipemello/titan_rl/runs/ef3zj2gs?nw=nwuserfelipemello
torch is removing torch/cuda/_annotate_cuda_graph_trace.py, the offline
joiner that merged CUDA-graph kernel annotations into an already-written
trace. Its replacement is a cuda_graph_annotations argument on
export_chrome_trace, which splices the annotations in during the export
pass, so the trace is never read back and re-written -- for the gzipped
traces the profiler has produced since
 pytorch#3483 that was paying the compression cost twice.

The trace handler now passes get_cudagraph_annotations() straight to
export_chrome_trace, and cudagraph_annotate_trace_post_processor goes
away with its import. Passing an empty mapping is defined to mean the
same as passing none, so the handler no longer needs the early return
the post-processor had.

The two post-processor tests are replaced by one that checks the handler
hands the captured annotations to the export of the .json.gz path. The
gzip round-trip case they covered is now torch's exporter picking
compression off the file suffix, tested there rather than here.

Depends on the torch-side change; until it lands in a nightly,
export_chrome_trace does not accept cuda_graph_annotations.

Authored with assistance from Claude Code.
## Summary

- Remove the in-tree `torchtitan.experiments.forge` implementation and
example.
- Remove its stale CODEOWNERS entry.
- Direct post-training users to the standalone
[TorchForge](https://github.com/meta-pytorch/torchforge) project.

This is a breaking change for users that import
`torchtitan.experiments.forge`
directly from TorchTitan `main`. Published TorchTitan releases are
unchanged.

## TorchForge validation
This validation confirms that TorchForge's core GRPO training path works
with
the released TorchTitan 0.2.0 package and PyTorch 2.9.0. It does not
claim that
every TorchForge workflow or the unmodified README installation was
validated.

I tested standalone TorchForge in a clean Python 3.12 uv environment on
an H100
with:
- `torch==2.9.0+cu128`
- `torchvision==0.24.0+cu128`
- `torchtitan==0.2.0`
- `vllm==0.13.0`
- `torchmonarch==0.2.0`

Results:
- The SFT and GRPO entry points imported successfully.
- All 311 TorchForge unit tests passed.
- A Qwen3-1.7B GRPO smoke run completed two training steps, including
  trainer-to-vLLM weight synchronization.

No TorchForge or TorchTitan source patches were needed. This was not a
fully
out-of-the-box dependency validation: local network restrictions
required a
local TorchStore checkout, optional metrics were disabled, and the GRPO
run used
a reduced two-step smoke configuration. The test therefore validates the
core
training and weight-synchronization path, not the exact README
installation.

Standalone TorchForge currently pins TorchTitan 0.2.0 and still consumes
the
Forge APIs shipped in that release. This test does not establish
compatibility
between TorchForge and TorchTitan `main` or 0.3.0.


### TorchTitan 0.3.0 compatibility

I also tested a Python 3.12 environment with `torch==2.14.0+cu130`,
`torchvision==0.29.0+cu130`, `torchao==0.18.0+cu130`, and
`torchtitan==0.3.0`. Current TorchForge is not compatible with this
release
stack:

- Its package metadata requires `torch==2.9.0` and `torchtitan==0.2.0`.
- The SFT entry point fails during import because TorchForge still
imports
  `torchtitan.experiments.forge.train_spec`, which is not present in
  TorchTitan 0.3.0.
- TorchForge also imports `torchtitan.experiments.forge.job_config`,
which was
  present in TorchTitan 0.2.0 but is not present in 0.3.0.

The failure occurs before GPU training begins. Supporting TorchTitan
0.3.0
requires changes in the standalone TorchForge project; no compatibility
patches
were applied for this test.

Co-authored-by: JessicaZhong <zhengjesszhong@gmail.com>
## Summary

- Add an opt-in Triton implementation of Qwen3.5 `OffsetRMSNorm`,
including fused forward, input-gradient, and weight-gradient kernels.
- Preserve the existing zero-centered parameterization, state-dict keys,
FP32 accumulation, DTensor sharding contracts, CPU fallback, and
`torch.compile` support.
- Add `rl_grpo_qwen3_6_27b_varlen_perf`, an 8-GPU Qwen3.6-27B RL config
that uses the Qwen3.5-compatible model flavor and enables the override
for both trainer and generator.

The standalone override can be enabled with:

```bash
--override.imports torchtitan.overrides.offset_rmsnorm.triton_offset_rmsnorm
```

The new RL config uses TP2 x FSDP2 for the trainer and TP4 for the
generator. Full activation checkpointing and BF16 Adam states keep the
27B trainer within H100 memory.

## Why

The stock operation computes `(1 + weight) * rmsnorm(input)` as separate
eager PyTorch operations. It materializes intermediates and launches
separate square, reduction, reciprocal-square-root, add, multiply, and
cast operations. The Triton path computes FP32 RMS statistics and the
offset scaling in one forward kernel. Its custom backward similarly
fuses the input-gradient work and reduces the weight gradient in Triton.

## Numerics and compatibility

- Forward, input gradient, and weight gradient are checked against both
an FP32 PyTorch reference and an FP64 golden reference.
- Coverage includes FP32, BF16, FP16, non-power-of-two dimensions, Qwen
hidden sizes 4096/5120, near-zero variance, and zero variance.
- `torch.library.opcheck` validates schema, FakeTensor, and autograd
registration.
- A fullgraph `torch.compile` forward/backward test passes.
- The override preserves checkpoint layout and applies to every Qwen3.5
`OffsetRMSNorm` config.
- The fused inference path successfully captures and replays inside vLLM
CUDA Graphs.

## Performance

All comparisons use the same code/configuration except for the override.

### Pretraining

Qwen3.5-27B, 4x H100, BF16, TP2 x FSDP2, sequence length 4096, 8192
unique tokens/step, full activation checkpointing, C4 packed text, and
BF16 Adam states. CUDA Graphs are disabled because variable
packed-document mask/GDN metadata currently changes structure between
batches. Statistics are steps 5-34 after warmup.

| Metric | Stock | Triton | Change |
| --- | ---: | ---: | ---: |
| Throughput/GPU | 1156.69 tok/s | 1235.30 tok/s | **+6.80%** |
| End-to-end step | 1.7707 s | 1.6583 s | **-6.35%** |
| MFU | 18.63% | 19.89% | **+1.27 pp** |
| Max reserved memory | 89.93 GiB | 89.93 GiB | unchanged |

A second paired short run measured +12.22% throughput; the table reports
the conservative longer run. Both 34-step C4 smoke runs followed the
same loss trend and ended at 3.2830 (stock) and 3.2800 (Triton).
Standard FP32 Adam states OOM while materializing optimizer state on
this topology, so both sides use `fused_opt_states_bf16`.

### Inference

Qwen3.5-compatible 27B model, 4x H100, TP4, BF16,
`torchtitan.experiments.rl.generate`, one sequence, 1024 generated
tokens, and vLLM `FULL_AND_PIECEWISE` CUDA Graphs with decode capture
size 1. The model was random-initialized to isolate execution
performance from the unrelated HF-to-TP4 checkpoint-loading path.

| Metric | Stock | Triton | Change |
| --- | ---: | ---: | ---: |
| Decode throughput | 51.814 tok/s | 60.227 tok/s | **+16.24%** |

## Validation

```text
pytest tests/unit_tests/cpu/test_triton_offset_rmsnorm_override.py -q
4 passed

pytest tests/unit_tests/gpu/test_triton_offset_rmsnorm_override.py -q
5 passed, 9 subtests passed

pytest torchtitan/experiments/rl/tests/test_generator.py -q
26 passed, 1 skipped

SKIP=pyrefly-check pre-commit run --all-files
all enabled hooks passed
```

The repository-wide Pyrefly hook was skipped locally because this
environment lacks the optional `torch_checkpointing` and `attn_gym`
packages and uses a PyTorch build with known API skew; the changed
Python files pass AST, flake8, ufmt, pydoclint, and codespell.
Summary:
Run retention only after the previous save has completed and before the
next save starts. `_purge_stale_checkpoints` receives the step being
saved so rank 0 can preserve the exact current published and staging
directory names even if another rank creates them while cleanup is
running. This avoids a cross-rank barrier in the checkpoint save path.

The method accepts an optional staging directory prefix and matches
those directories separately instead of teaching `_parse_step` about
backend-specific temporary names. Valid published checkpoints alone
participate in retention, and the manager keeps `keep_latest_k - 1` of
them to reserve a slot for the current save. Unknown directory names
remain untouched.

Abandoned directories from other steps use the existing purge thread.
The current step is excluded before paths are queued, so background
deletion cannot target the active save. The DCP manager moves its purge
call from after save dispatch to the settled window before dispatch and
passes the current step.

Test Plan:
`pytest tests/unit_tests/cpu/test_checkpoint.py
tests/unit_tests/cpu/test_torch_checkpointing.py -q`

64 passed.

Tests cover DCP purge ordering, current-save protection, retention slot
reservation, incomplete checkpoint cleanup through the purge thread, and
canonical parsing.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with
[ReviewStack](https://reviewstack.dev/pytorch/torchtitan/pull/4197).
* pytorch#4457
* pytorch#4277
* pytorch#4279
* pytorch#4278
* pytorch#4191
* pytorch#4190
* pytorch#4189
* pytorch#4188
* __->__ pytorch#4197
Summary:
`TorchCheckpointingManager` has been config-only since pytorch#4058: it builds
its
backend and then raises `NotImplementedError` from every operation. This
implements the save path against that backend, leaving load for a later
change.

Saves follow the same cadence contract as the DCP manager -- interval,
first
step, and last step -- and hand the flattened state dict to the backend,
which
returns a future tracked in `save_future` and awaited by
`maybe_wait_for_saving`.
On a step where no save is due, the manager prewarms the backend's
staging
buffers once, so the first real save does not also pay for pinned-memory
allocation.

The manager constructs each backend configuration from a concrete saver
configuration. Regular saves use an asynchronous saver with
pinned-memory
staging and a checkpoint barrier. Load-only runs use a synchronous saver
with
no barrier. The final write uses a fresh synchronous saver with a
barrier so it
must complete before the process exits. Building each mode directly
avoids
copying and incrementally rewriting nested dataclass configurations.

Backend storage is a defaulted `__init__` parameter rather than a
`Config`
field. `Configurable.Config` is Tyro-parsed, and a backend storage
object is
not a command-line option; callers needing remote storage pass it
programmatically. The backend config receives the same storage object,
so
saves, loads, and path probes agree.

Retention reuses the shared `purge_thread` worker from
`checkpointer/base.py`,
draining through the backend `Storage` abstraction rather than the
filesystem
directly, and matches the same `step-(\d+)` names the DCP manager
purges.

`last_save_in_hf` is rejected in `Config.__post_init__`; HF export lands
in a
later change and would otherwise fail deep in the backend.

This adapts the implementation to the current OSS layout:
`components/torch_checkpointing_manager.py` is now
`components/checkpointer/torch_checkpointing.py`,
`CheckpointManagerConfig` is
`BaseCheckpointManager.Config`, `purge_worker` is `purge_thread`, and
`LRSchedulersContainer` comes from `components.optimizer`. The earlier
version
also carried its own `if not self.enable` guard in every public method;
those
are gone, since the base class now owns that check and dispatches to the
`_save` / `_wait_for_saving` / `_maybe_wait_for_staging` / `_close`
hooks.

`_should_save` and `_create_checkpoint_id` move to the base because they
depend
only on fields already declared by `BaseCheckpointManager.Config`.
Retention,
step discovery, state selection, and the final-step payload stay
per-manager
because each backend reaches storage differently.

Test Plan:
`pytest tests/unit_tests/cpu/test_torch_checkpointing.py`: 23 passed.

The tests cover save cadence and future tracking, prewarm running once
before
the first scheduled save, load-only selecting a synchronous barrier-free
backend, load-only never constructing a barrier, staging waits through
the
backend lock, configured save timeouts, cleanup after failed saves,
retention,
and the final synchronous model-only save.

Also verified:
- The required backend APIs exist in `torch_checkpointing` 0.1.0.
- `TorchCheckpointingManager.__abstractmethods__` is empty.
- `ufmt` and `flake8 --config=.flake8` pass on both changed files.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with
[ReviewStack](https://reviewstack.dev/pytorch/torchtitan/pull/4188).
* pytorch#4457
* pytorch#4277
* pytorch#4279
* pytorch#4278
* pytorch#4191
* pytorch#4190
* pytorch#4189
* __->__ pytorch#4188
For some reason, `varlen` is routed to FA3 on Hopper GPUs. Is there a
particular reason for this?

According to the [FlashAttention
repository](https://github.com/dao-ailab/flash-attention?), FA4 is
optimized for Hopper and Blackwell GPUs.

This PR routes Hopper to FA4 too

---
# UPDATE

I am repurposing this PR to add a small comment about this, so that
others do not fall for the same mistake. If you think it is too much,
you can simply close this.

Sorry for the mess here!
## Summary
Docs-only cleanup of stale model notes. No invented parity/MFU numbers.

- GPT-OSS README: CP/PP are already covered by model-suite jobs; drop
the stale TODO and note the FlexAttention zero-bubble caveat
- Qwen3 README: remove "under development", fix the aux-loss-free claim
to match the original recipe (aux loss), and list missing
`config_registry` sizes
- Root README: replace helper-script bullets that do not exist with
`download_hf_assets.py`, `scripts/checkpoint_conversion/`, and
`scripts/loss_compare.py`
- Add `torchtitan/models/llama3/README.md` (tokenizer, run commands,
parallelism table). Addresses the Llama 3 slice of pytorch#2354
- Flux README: mark `get_nparams_and_flops()` as done

## Test plan
- [x] Cross-checked recipes/tests mentioned in the new text
(`gpt_oss_pp+fsdp+cp+ep+sacop`, `sft_qwen3_8b_math`, `llama3_8b_mxfp8`,
Flux FLOPs helper)
- [ ] Docs review only
Introducing `with subgraph(name, role)` that allows to outline parts of
the graph into standalone invoke_subgrah() regions, that will be
compiled independently by inductor, keeping them in the same graph.


Example from the test:
```
    def test_subgraph_contextmanager_example_outlines_region(self):
        def f(x):
            with subgraph("chunk_0", role="loss_chunk"):
                a = torch.ops.aten.sin.default(x)
                b = torch.ops.aten.cos.default(a)
            return torch.ops.aten.add.Tensor(b, x)
 ```

This allows to express for example "chunking", when we do not want inductor to fuse/reuse across chunk iterations,
while having them in one big graph.

This allows then to do optimizations across those subgraphs.

E.g. CSE on chunk subgraphs to extract the same fsdp-unshard.

To match dynamo-aotautograd peak memory we have to also add features that exist in aotautograd:
1. applying mincut between forward and backward of the hops - (to have the same save/recompute logic), this was important for recomputes of fp32 casts.
2. Using AotAutograd decompositions before applying mincut, which allows for mincut to save decomposed edges.
## Summary

This PR overhauls MXFP8 linear training in TorchTitan. The key design
choice is a narrow TorchAO/TorchTitan boundary: **TorchTitan relies on
TorchAO only for MXFP8 quantization and layout kernels, while TorchTitan
owns autograd, FSDP cache management, and model policy.** Thanks to
Vasiliy Kuznetsov for adding the TorchAO kernel support used here.

The main changes are:

- FSDP owns the quantized weights and scales, so their lifetime follows
the normal FSDP unshard/reshard lifecycle and composes with both values
of `reshard_after_forward`.
- Weight quantization is pluggable. The built-in strategy uses 2D
`32x32` scaling for weights, while inputs use standard 1D scaling.
- `MXFP8Linear` can save either the original BF16 input or its
columnwise-quantized representation for WGRAD. TorchTitan model configs
select the optimized policy only where the BF16 input is not retained
elsewhere.

## TorchAO and TorchTitan boundary

TorchTitan currently uses the following kernel-level operations from
TorchAO:

- `mxfp8_quantize_cuda` for rowwise and columnwise activation
quantization.
- `triton_to_mxfp8_32x32_swizzle_dim0_and_dim1` for 2D weight
quantization and Blackwell-ready layouts.
- `triton_mx_block_rearrange` for scale layout conversion.

TorchTitan owns everything that interacts with training structure:

- `_MXFP8LinearFunction` and `MXFP8Linear`.
- FSDP tensor subclasses and pre/post-all-gather hooks.
- Quantized-weight storage and lifetime.
- The pluggable weight strategy.
- Model-specific activation-storage policy.

The current kernels are sufficient for this integration. More heavily
fused kernels can be added later as small, independent TorchAO changes
without moving FSDP or parallelism policy into TorchAO.

## FSDP-managed quantized weights

The FSDP post-all-gather hook quantizes the unsharded BF16 weight and
returns the independent MXFP8 qdata and scale tensors for FSDP to
manage. There is no separate module-level or autograd-level weight
cache.

This gives the quantized representation the same lifecycle as the
unsharded FSDP parameter:

| `reshard_after_forward` | Behavior |
|---|---|
| `False` | Quantize on the first unshard and reuse the MXFP8 operands
across PP microbatches. PP can retain them through the microbatch
sequence and release them after the final backward. |
| `True` | Reshard after forward. Backward performs another BF16
all-gather and post-all-gather quantization. |

FSDP keeps the compute-weight object stable and allocates, frees, or
refills its inner tensors through the post-all-gather `out=` contract.
This lets us reuse the existing FSDP state machine instead of
maintaining a second cache lifecycle in `MXFP8Linear`.

The implementation also uses the tensor-subclass API from
[pytorch/pytorch#194114](pytorch/pytorch#194114),
which allows FSDP to release the BF16 all-gather output after the
independent MXFP8 representation has been constructed.

## Pluggable quantization strategy

The practical default used by this PR is:

| Operand | Scaling |
|---|---|
| Weights | 2D `32x32` |
| FPROP inputs | 1D `1x32` |
| WGRAD inputs | 1D `32x1` |

There are two reasons for this choice:

1. With 1D weight scaling, the scale groups needed by FPROP and DGRAD
have different orientations. The two weight representations must
therefore be quantized separately and may contain different quantized
values. With square `32x32` tiles, the quantization groups are invariant
under transpose, so DGRAD qdata can be a transpose view of the FPROP
qdata.
2. Inputs remain one-dimensional because a 2D activation scale may span
multiple sequence positions. In causal attention, that would allow a
token's quantization scale to depend on future tokens.

The PR intentionally provides only the built-in 2D weight strategy. The
implementation remains extensible through
`MXFP8WeightQuantizationStrategy`: a different strategy supplies its
quantization implementation, and strategies with aliased storage can
additionally describe how their logical GEMM operands are reconstructed
from the independent tensors managed by FSDP.

Every strategy must return qdata and blocked scales compatible with the
`scaled_mm` scaling and swizzle contract used by `MXFP8Linear`.

## Revamped `MXFP8Linear`

The previous activation path was:

```text
Forward:  rowwise-quantize X + save BF16 X
Backward: retrieve BF16 X + columnwise-quantize X for WGRAD
```

This remains the conservative default, but the PR adds an optimized
policy:

```text
Forward:  rowwise- and columnwise-quantize X + save columnwise MXFP8 X
Backward: retrieve the saved columnwise MXFP8 X
```

| `wgrad_input_storage` | Forward quantization | Saved for WGRAD |
Backward work |
|---|---|---|---|
| `high_precision` (default) | Rowwise only | Original BF16 `X` |
Columnwise-quantize `X` |
| `quantized` | Fused rowwise + columnwise | Columnwise qdata and scales
| Reuse saved operand |

The quantized policy can reduce saved-activation memory and avoids a
quantization pass during backward. It also allows the rowwise and
columnwise outputs to be produced in one kernel call, reducing HBM
traffic. This design is similar to the activation handling used by
NVIDIA Transformer Engine.

The important caveat is that this saves memory only when no other
operation retains the same BF16 `X`. If BF16 `X` is already retained
elsewhere, saving an additional quantized representation increases
memory. We therefore keep `high_precision` as the default to avoid an
unexpected memory regression or OOM, and select `quantized` explicitly
for audited modules.

| Is the same BF16 input retained elsewhere? | Policy | Reason |
|---|---|---|
| Yes or unknown | `high_precision` | Avoid keeping both BF16 and
quantized WGRAD inputs. |
| No | `quantized` | Replace the saved BF16 WGRAD input with the smaller
quantized representation. |

The current TorchTitan policies are:

| Model | Modules using `quantized` |
|---|---|
| Llama 3 | `attention.qkv_linear.wqkv`, `feed_forward.w2` |
| DeepSeek V3 | `attention.wkv_b`, `feed_forward.w2`,
`shared_experts.w2` |
| Flux | None; it retains the conservative default |

All other converted linears use `high_precision`. The standard Trainer
and GraphTrainer share these model policies. The grouped-expert/Grouped
GEMM path is unchanged by this PR.

## Testing

- Unit coverage for activation policies, pluggable weight strategies,
and FSDP tensor reconstruction.
- FSDP lifecycle coverage for both values of `reshard_after_forward`,
including PP cache reuse and final-backward release.
- Compiled FSDP, PP, and GraphTrainer training runs.

## Appendix: RAF and activation-checkpointing trade-offs

Activation checkpointing affects which weight representations are needed
during the backward unshard. It does not change the WGRAD input-storage
policy described above.

The current post-all-gather hook constructs both FPROP and DGRAD weight
operands on every actual unshard:

| RAF | AC | Current behavior | Ideal behavior |
|---|---|---|---|
| `False` | Off | Construct both once and reuse | Construct both once
and reuse |
| `False` | On | Construct both once and reuse | Construct both once and
reuse |
| `True` | Off | Construct both in forward and both again in backward |
FPROP in forward, DGRAD in backward |
| `True` | On | Construct both in forward and both again before backward
| FPROP in forward, both before backward |

For `RAF=False`, the current behavior is already ideal because both
representations are constructed once and reused.

For `RAF=True`, the initial forward only needs FPROP. Without AC, the
backward unshard only needs DGRAD. With AC, however, the backward
unshard must support both the recomputed forward and DGRAD.

FSDP internally knows its training phase and RAF setting, but this
context is not currently exposed through the post-all-gather hook. In
addition, checkpoint recomputation is not always known when the backward
unshard begins. This PR deliberately keeps the hook simple and correct
by constructing both representations. A future FSDP hook extension could
expose the unshard context and enable phase-specific construction.
anijain2305 and others added 4 commits September 4, 2026 07:26
pytorch#4371)

Core FSDP automatically pads for uneven sharding. 

However, `mxfp8` linear uses pre/post all gather hooks where the
responsibility now lies on the the tensor subclass to perform the
padding during pre-gather and then unpad (narrow) during post-gather.
This PR does that.

For testing, we forcefully create a mxfp8 linear with rows not directly
divisible by the number of shards (e.g. 96/5). This is more common in
`efsdp` where number of experts might not be divisible by fsdp degree.
We will see this more frequently come up when we release faster MoE
implementations.

---

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
… -> renderers.Renderer

Address the pytorch#4444 review (tianyu-l, wwwjn, HosseinKaviani-H).

- `RendererConfig(Configurable.Config)` is the renderer slot on `Controller.Config`;
  `build(*, tokenizer)` returns the `renderers` protocol object. `RenderersLibraryConfig`
  holds the library's typed config as `renderers_config`, serializes it locally
  (`model_dump(mode="json")`) and calls `create_renderer`. `MuseGlimmerRendererConfig` is a
  plain dataclass with its own `build`; `TorchTitanRendererConfig`, `renderer_cls` and the
  `isinstance` dispatch in `build_renderer` are gone.
- `Controller.Config.tokenizer: HuggingFaceTokenizer.Config`; tokenizer and renderer are
  built from config in the controller, the rollout workers (`setup_async` carries
  `tokenizer_config`) and `generate.py`.
- `torchtitan/config/configurable.py` reverted to main; no core change.
- `RendererTokenizer` -> `RendererTokenizerWrapper`.
- `TokenEnv.step` passes the env's `tools` to `parse_response`, as it already did for
  `render_ids` and `bridge_to_next_turn`.
- Recipes: `renderer=RenderersLibraryConfig(renderers_config=Qwen3RendererConfig(...))`;
  the `--renderer.*` CLI flags are gone (renderer options are recipe-owned; every CI recipe
  already sets `enable_thinking=False`).

Validation: alphabet sort Qwen3-0.6B, 2 GPUs, 30 steps, validation reward 0.181 -> 0.526.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n build

The dataclass config replaced a pydantic one, so bad values were accepted silently:
`thinking_retention="everything"` bridged as "tool_cycle", and a string in either bool knob
was truthy (`answer_from_reasoning_fallback="false"` promoted reasoning to the answer).
`__post_init__` now rejects them. `build` passes `replace(self)` so a recipe object edited
after build cannot desynchronize full renders from bridging (the renderer caches its bridge
policy at construction but reads `retain_reasoning_in_history` live).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.